Blog

Software Design Patterns in Angular

25 Sep 2023

Software Design Patterns, oder deutsch Entwurfsmuster, finden sich überall und somit auch im Angular-Framework. Arbeitet man mit Angular, kommt man auch bei der Entwicklung täglich mit Design Patterns in Berührung. Wenn wir verstehen, wo diese Muster Verwendung finden, was sie tun und wie sie funktionieren, können wir sie auch besser nutzen. Das wiederum befähigt uns, bessere und robustere Anwendungen zu entwickeln.

ENTWURFSMUSTER VERSTEHEN UND VERWENDEN

Generell sind Entwurfsmuster eine allgemeine wiederverwendbare Lösung für ein häufig wiederkehrendes Problem in einem bestimmten Kontext beim Softwaredesign. Sie basieren auf objektorientierten Kodierungstechniken wie Vererbung, Abstraktion, Polymorphismus und verbessern die Lesbarkeit des Codes.

TypeScript an sich ist zum Beispiel bereits so aufgebaut, dass es diese objektorientierten Programmiertechniken verwendet. Einige dieser Muster erfordern Programmierprinzipien wie Single Responsibility, Trennung unterschiedlicher Belange (Separation of Concerns) oder SOLID-Prinzipien. Die Verwendung von Entwurfsmustern schafft auch Konsistenz in einer Codebasis. Sie verbessert die Wartbarkeit, ermöglicht eine bessere Erweiterbarkeit und vereinfacht die Testbarkeit einer Softwarelösung. Dabei können vorhandene Muster verwendet werden, um zu vermeiden, atypische Lösungen für häufige Probleme zu schaffen – oder das Rad immer wieder neu zu erfinden. Auch bei der Arbeit im Team haben Software-Design-Patterns Vorteile, denn mit ihnen besitzen Entwicklerteams ein gemeinsames Vorgehen für die Implementierung bestimmter Teile der Anwendung.

Dieser Artikel beschäftigt sich mit oft verwendeten Entwurfsmustern, wie dem Template Pattern, dem Composite Pattern und dem Observer Pattern, die im Angular-Framework implementiert sind. Darüber hinaus werden Patterns beleuchtet, die bei der Entwicklung mit Angular verwendet werden können oder müssen, da das Framework sie vorgibt. Hierzu gehören das Mediator Pattern, Dependency Injection und das Facade Pattern. Somit handelt es sich nicht um eine Liste aller Patterns, die Angular verwendet oder die sich mit Angular verwenden lassen, sondern um eine exemplarische Annäherung an die Möglichkeiten, die Design Patterns bieten.

Im Angular-Framework implementierte Design Patterns

Angular nutzt Entwurfsmuster in fast allen Bereichen. Man findet sie in den Templates für die Benutzeroberfläche, in Komponenten und in Services. Darüber hinaus bietet das Framework selbst Konzepte, die die Verwendung ganz bestimmter Design Patterns für die Implementierung von Businesslogik vorsehen.

Eine der Hauptkomponenten einer Angular-Anwendung ist das Modul. Es fungiert wie eine Bibliothek oder ein Container für verwandte Komponenten. Jede Angular-Anwendung besitzt mindestens ein Modul: das Root-Modul (app.module.ts); siehe dazu Listing 1.

Listing 1: Das Root-Modul app.module.ts einer neu generierten Angular-Anwendung

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
 
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
 
@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    AppRoutingModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }
 

Module können in beliebig vielen weiteren Modulen organisiert werden. Je nach Bedarf der Lösung, die man anstrebt, ist es gängige Praxis, ein Core Module, ein Shared Module oder auch Feature Modules zu erstellen. Modules geben uns die Möglichkeit, Struktur innerhalb der Anwendung zu implementieren – und diese Struktur wiederum erlaubt es uns, Design Patterns bei der Entwicklung zu verwenden.

BRINGEN SIE LICHT INS ANGULAR-DUNKEL

Die ersten Schritte in Angular geht man am besten im Basic Camp.
→ Nächster Termin: 17. - 19. Juni, online

Template Method Pattern (Schablonenmethode)

Das Template Method Design Pattern ist ein Verhaltensmuster (Behavioral Pattern). Es befasst sich, wie alle Muster, die in diese Kategorie fallen, mit Algorithmen und der Zuweisung von Verantwortlichkeiten zwischen Objekten. Das Besondere an verhaltensbasierten Entwurfsmustern ist, dass sie nicht nur die Muster von Objekten und Klassen beschreiben, sondern auch die Muster der Kommunikation zwischen ihnen.

Das Template Method Pattern ermöglicht es, einen bestimmten Prozess oder Ablauf zu erstellen, der jedes Mal, wenn eine Methode ausgeführt wird, in der richtigen Sequenz implementiert wird. Bei Angular sehen wir dieses Pattern in den Lifecycle Hooks [1] (ngOnInitngOnDestroy etc.) unserer Komponenten. Sie werden immer in einer bestimmten festgelegten Reihenfolge ausgeführt und helfen uns so dabei, Funktionalität in genau dem richtigen Moment des Lebenszyklus der Komponente auszuführen (Listing 2).

Listing 2: Angular Lifecycle Hooks in der Reihenfolge, in der sie ausgeführt werden

import {
  AfterContentChecked,
  AfterContentInit,
  AfterViewChecked,
  AfterViewInit,
  Component,
  DoCheck,
  OnChanges,
  OnDestroy,
  OnInit,
  SimpleChanges,
} from '@angular/core';
 
@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss'],
})
export class AppComponent
  implements
    OnChanges,
    OnInit,
    DoCheck,
    AfterContentInit,
    AfterContentChecked,
    AfterViewInit,
    AfterViewChecked,
    OnDestroy
 
{
  ngOnChanges(): void {}
 
  ngOnInit(): void {}
 
  ngDoCheck(): void {}
 
  ngAfterContentInit(): void {}
 
  ngAfterContentChecked(): void {}
 
  ngAfterViewInit(): void {}
 
  ngAfterViewChecked(): void {}
 
  ngOnDestroy(): void {}
}
 

Composite Pattern (Kompositum)

Das Composite Pattern ist ein Entwurfsmuster, das zur Kategorie der Strukturmuster (Structural Patterns) gehört. Das Composite Pattern definiert, dass eine Gruppe von Objekten genauso zu behandeln ist, wie eine einzelne Instanz eines Objekts. Die Absicht eines Kompositums ist es, Objekte in Baumstrukturen zusammenzusetzen, um Teil-Ganzes-Hierarchien abzubilden. Vereinfacht ausgedrückt geht es bei diesem Muster darum, dass Knoten wieder andere Knoten besitzen, die auch wiederum Knoten beinhalten – wie eine Matroschka-Puppe.

Denken wir an Angular, kann dieses Prinzip generell auf Komponenten übertragen werden. Views sind in der Regel hierarchisch aufgebaut, sodass ganze UI-Abschnitte oder Seiten als Einheit geändert oder ein- und ausgeblendet werden können. Das Template, das unmittelbar mit einer Komponente verbunden ist, definiert die Host-View dieser Komponente. Die Komponente kann wiederum auch eine View-Hierarchie definieren, die eingebettete Views enthält, die von anderen Komponenten gehostet werden. Genauso baut Angular die Struktur der gesamten Benutzeroberfläche auf (Abb. 1).

varga_designpatterns_1.tif_fmt1.jpgAbb. 1: Angular-Komponentenhierarchie

Observer Pattern

Beginnt man mit der Entwicklung mit Angular, sind Observables eins der ersten zentralen Konzepte des Frameworks, mit denen man sich beschäftigt. Eine Angular-Anwendung ohne Observables zu erstellen, ist schier unmöglich.

Observables werden sowohl im Angular-Framework selbst als auch bei der Entwicklung von Angular-Anwendungen sehr intensiv genutzt. Wie funktioniert das Ganze? Man subskribiert ein Objekt, das Observable, und reagiert auf dessen Antworten. Das entspricht der Definition des Observer Pattern. Das Observer Pattern gehört zur Kategorie der Verhaltensmuster. Es dient zur Weitergabe von Änderungen an einem Objekt an von diesem Objekt abhängigen Strukturen. Dieses Muster ähnelt (ist aber nicht identisch mit) dem Publish/Subscribe-Entwurfsmuster.

Um ein selbst erstelltes Observable auszuführen und dessen Benachrichtigungen zu empfangen, wird seine subscribe()-Methode aufgerufen und ein Observer übergeben. Das ist ein JavaScript-Objekt, das die Handler für die empfangenen Benachrichtigungen definiert. Der subscribe()-Aufruf gibt ein Subscription-Objekt zurück, das eine unsubscribe()-Methode hat. Letztere muss aufgerufen werden, um den Empfang der Benachrichtigungen dieses Observables zu beenden.

Listing 3 ist der offiziellen Angular-Dokumentation entnommen [2] und veranschaulicht, wie ein Observer verwendet werden kann, um Aktualisierungen der Geolocation bereitzustellen.

Listing 3: Beispiel-Observable aus der Angular-Dokumentation

// Create an Observable that will start listening to geolocation updates
// when a consumer subscribes.
const locations = new Observable((observer) => {
  let watchId: number;
 
  // Simple geolocation API check provides values to publish.
  if ('geolocation' in navigator) {
    watchId = navigator.geolocation.watchPosition(
      (position: GeolocationPosition) => {
        observer.next(position);
      },
      (error: GeolocationPositionError) => {
        observer.error(error);
      }
    );
  } else {
    observer.error('Geolocation not available');
  }
 
  // When the consumer unsubscribes, clean up data ready for next   // subscription.
  return {
    unsubscribe() {
      navigator.geolocation.clearWatch(watchId);
    },
  };
});
 
// Call subscribe() to start listening for updates.
const locationsSubscription = locations.subscribe({
  next(position) {
    console.log('Current Position: ', position);
  },
  error(msg) {
    console.log('Error Getting Location: ', msg);
  },
});
 
// Stop listening for location after 10 seconds.
setTimeout(() => {
  locationsSubscription.unsubscribe();
}, 10000);
 

ABTAUCHEN IM DEEP DIVE

Im Fortgeschrittenen Camp tauchen Sie ab unter die Oberfläche einer modernen Angular-Anwendung.
→ Nächster Termin: 13. - 15. Mai, München

Patterns zur Implementierung von Businesslogik – Mediator Pattern

Das Mediator- bzw. Vermittler-Entwurfsmuster gehört zur Kategorie der Verhaltensmuster und stellt eine der Möglichkeiten dar, eine lose gekoppelte Kommunikation zwischen Komponenten zu ermöglichen. Dabei erfolgt die Komponentenkommunikation über den Vermittler, einen „Mittelsmann“, sodass eine Komponente A niemals direkt mit einer Komponente B kommuniziert.

Wenn eine Komponente A also Daten benötigt, wird eine andere die Daten über Bindungen (Bindings) an die Eingabeeigenschaften (Input Properties) der Komponente bereitstellen. Wer diese andere Komponente ist, interessiert die Komponente A nicht. Muss eine Komponente B der Außenwelt Daten zur Verfügung stellen, emittiert sie Ereignisse (in Angular mit dem @Output Decorator markiert) mit der Nutzlast der Daten. An wen sie die Ereignisse emittiert? Auch das interessiert die Komponente B nicht. Die Komponente, die die Daten benötigt, lauscht auf die Events (in Angular mit dem @Input Decorator markiert) dieser Komponente B und kümmert sich so darum, ihre benötigten Daten zu erhalten.

In Listing 4 bis 8 wird exemplarisch der Kommunikationsweg der Komponente buy zur Komponente cart über einen Mediator, der Parent-Komponente beider Children, gezeigt.

Listing 4: buy.component.html

<button (click)="onBuy('a123')">buy me!</button>
 

Listing 5: buy.component.ts

import { Component, EventEmitter, Output } from '@angular/core';
 
@Component({
  selector: 'app-buy',
  templateUrl: './buy.component.html',
  styleUrls: ['./buy.component.scss'],
})
export class BuyComponent {
  @Output() readonly purchasedItemIdEvent = new EventEmitter<string>();
 
  onBuy(purchasedItemId: string) {
    this.purchasedItemIdEvent.emit(purchasedItemId);
  }
}
 

Listing 6: mediator.component.html

<app-buy (purchasedItemIdEvent)="setPurchasedItemId($event)"></app-buy>
<app-cart [purchasedItemId]="purchasedItemId"></app-cart>
 

Listing 7: mediator.component.ts

import { Component } from '@angular/core';
 
@Component({
  selector: 'app-mediator',
  templateUrl: './mediator.component.html',
  styleUrls: ['./mediator.component.scss'],
})
export class MediatorComponent {
  purchasedItemId: string = '';
 
  setPurchasedItemId(itemId: string) {
    this.purchasedItemId = itemId;
  }
}
 

Listing 8: cart.component.ts

import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
 
@Component({
  selector: 'app-cart',
  templateUrl: './cart.component.html',
  styleUrls: ['./cart.component.scss'],
})
export class CartComponent implements OnChanges {
  @Input() purchasedItemId: string = '';
 
  ngOnChanges(changes: SimpleChanges): void {
    console.log('changes', changes);
  }
}
 

Dependency Injection

Das Dependency Injection Pattern ist ein Entwurfsmuster der objektorientierten Programmierung. Das Pattern beschreibt die Verwaltung von Objektabhängigkeiten zur Laufzeit. Wenn beispielsweise ein Objekt während der Initialisierung ein anderes Objekt benötigt, wird diese Dependency an einer zentralen Stelle verwaltet und wird nicht vom initiierten Objekt selbst generiert.

Laut der Dokumentation von Angular [3] ist Dependency Injection ein Entwurfsmuster und ein Mechanismus zur Erstellung und Bereitstellung einiger Teile einer Anwendung für andere Teile einer Anwendung, die diese benötigen. Angular Services folgen grundlegend diesem Muster. Wird über den Befehl ng generate service <name> ein Service erstellt, so ist dieser auch gleich per Default mit einem @Injectable() Decorator ausgestattet. Dieser spezifiziert, dass Angular diese Klasse in ihrem Dependency-Injection-System verwenden kann (Listing 9).

Listing 9: ProductService mit @Injectable() Decorator

import { Injectable } from '@angular/core';
 
@Injectable({
  providedIn: 'root',
})
export class ProductService {
  getProducts() {
    // logic
  }
}
 

 

Verwendet werden kann der Service dann sowohl in anderen Services als auch in Komponenten. In allen Fällen erfolgt eine Constructor Injection, wobei die Abhängigkeit über den jeweiligen Konstruktor zur Verfügung gestellt wird.

Listing 10: Verwendung des ProductService in app.component.ts via Constructor Injection

import { Component } from '@angular/core';
import { ProductService } from './cart-facade.service';
 
@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss'],
})
export class AppComponent {
  constructor(productService: ProductService) {}
 
  getProducts() {
    this.productService.getProducts();
  }
}
 

Newsletter

Jetzt anmelden & regelmäßig wertvolle Insights in Angular sowie aktuelle Weiterbildungen erhalten!

Facade Pattern (Fassade)

Das Facade Pattern gehört zur Kategorie der Strukturmuster (Structural Design Patterns). Das Entwurfsmuster bietet eine vereinfachte Schnittstelle zu einem komplexen System von Klassen, Schnittstellen und Objekten. In einer Angular-Anwendung kann dieses Design Pattern verwendet werden, um die Interaktionen zwischen Komponenten und Services zu vereinfachen.

Häufig wird das Facade Pattern verwendet, um die Komplexität eines Systems hinter einer einfacheren, benutzerfreundlicheren Schnittstelle zu verbergen. Das ist nützlich, wenn eine konsistente Schnittstelle für ein komplexes System bereitgestellt werden soll, die einfacher zu verwenden, zu testen und zu warten ist. Um das Entwurfsmuster in Angular zu implementieren, kann ein Facade Service implementiert werden. Dieser Service kann dem Rest der Anwendung nur die notwendige Funktionalität zur Verfügung stellen, während die Implementierungsdetails dahinter verborgen bleiben.

Für das Beispiel in Listing 11 kann angenommen werden, dass ein komplexes System aus Services für einen Einkaufswagen (Cart) vorliegt. Die Verwendung des CartFacade Services würde diese Komplexität für die konsumierenden Komponenten reduzieren. Der Service fungiert als vereinfachte Schnittstelle zum komplexen Cart-System.

Listing 11: cart-facade.service.ts

import { Injectable } from '@angular/core';
 
@Injectable({
  providedIn: 'root',
})
export class CartFacadeService {
  constructor(
    private orderService: OrderService,
    private productService: ProductService,
    private userSessionService: UserSessionService,
    private localStorageService: LocalStorageService
  ) {}
 
  public addToCart(productId: string, quantity: number) {
    // add to cart logic
    const product = this.getProduct(productId);
    this.userSessionService.add(product, quantity);
    this.localStorageService.save();
  }
 
  public buy(productId: string, quantity: number) {
    // purchase logic
    this.orderService.purchase(productId, quantity);
  }
 
  public getProduct(productId: string) {
    // product logic
    this.productService.getProduct(productId);
  }
}
 

Mittels Dependency Injection kann der Facade Service dann wiederum in die Komponenten injiziert werden, die ihn benötigen (Listing 12).

Listing 12: Verwendung des CartFacadeService in app.component.ts

import { Component } from '@angular/core';
import { CartFacadeService } from './cart-facade.service';
 
@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss'],
})
export class AppComponent {
  constructor(cartFacadeService: CartFacadeService) {}
 
  addToCart() {
    this.cartFacadeService.addToCart();
  }
 
  buy() {
    this.cartFacadeService.buy();
  }
}
 

Fazit

Design Patterns stellen wiederverwendbare Lösungen für häufig auftretende Probleme im Softwaredesign dar. Manchmal werden Entwurfsmuster unzweckmäßig oder übermäßig verwenden. Diese Verwendungen werden als Antipatterns bezeichnet, sie gilt es also zu vermeiden.

Observables sind ein leistungsfähiges Muster zur Verwaltung asynchroner Datenströme. Es ist jedoch möglich, sie unsachgemäß oder übermäßig zu nutzen, was zu unerwünschter Komplexität oder schlechter Performance führt. Ein häufiges Antipattern ist das mehrfache Subskribieren von Observables, was zu redundanten API-Aufrufen führt. Eine weitere unzulässige Nutzungsweise besteht darin, dass versäumt wird, sich von der Subskription eines Observables abzumelden (unsubscribe()), was zu Memory Leaks führt.

Um Antipatterns zu vermeiden, ist es wichtig, die Stärken und Schwächen der einzelnen Entwurfsmuster zu verstehen und sie in Angular-Anwendungen angemessen einzusetzen. Darüber hinaus ist es essenziell, Best Practices für die Angular-Entwicklung zu befolgen, wie z. B. die Verwendung von Dependency Injection, die Einhaltung des Single-Responsibility-Prinzips und das Schreiben von testbarem Code.